agent 寫的程式碼,跑起來就是你。2026-10-12,我請 agent 寫測試,它跑了一行
bun test,測試的清理程式把我家目錄裡的.config、.local、.cache、.omp刪了,沒有備份。那一行指令裡沒有一個危險的字:核准、allowlist、hook、規則,看的都是那一行字,刪檔卻發生在它啟動的行程裡。擋得住這種刪除的只有一樣東西:核心層級的沙箱,而且它得真的包住那一行。Cursor 在 Linux 上有這個沙箱,那天那個 shell 卻不在裡面;omp 原生沒有,但一支擴充能把每一個 bash 呼叫都包進去。事後要查也一樣:紀錄記下了那一行指令,沒記下它做了什麼,而且就放在被刪的那個家目錄裡;留下來的紀錄改了,兩邊的產品都看不出來。
Day28 處理的是「把 agent 開給別人叫」,最後收在一句:擋得住,不等於查得清。前面幾天談的,都是從外面來的東西:藏在內容裡的餌、別人寫的外掛、接進來的 MCP、叫它做事的外部程式。今天沒有攻擊者。agent 沒有被騙,它只是相信了一個自己沒驗證過的前提,寫了一段清理程式,然後照常跑測試。
這種事不是第一次。2025 年 7 月,Replit 的 agent 在程式凍結期間刪了正式資料庫(Day23 講過);2025 年 11 月底,Google Antigravity 為了清專案快取跑了 rmdir /s /q d:\,整個 D 槽沒了;2025 年 12 月,Claude Code 跑了 rm -rf tests/ patches/ plan/ ~/,最後那個 ~/ 把使用者的家目錄整個刪掉。那幾次,危險的字至少還寫在指令裡,看指令的人、比對指令的規則,還有機會攔下來。我這一次,指令是 bun test。
OWASP 的 Agentic 應用十大風險(2026 版)把這個形狀寫在 ASI02「Tool Misuse and Exploitation」:agent 在自己被授權的權限之內,把一個正當的工具用在不安全、非預期的地方,「for example deleting valuable data」;agent 自己生成、自己執行的程式碼,則是 ASI05「Unexpected Code Execution」。
今天就用這個事故回答兩個問題。一是擋得住嗎:agent 跑的程式碼在你的權限下動到家目錄,誰看得到、誰擋得住,擋不住的時候還剩什麼。二是查得清嗎:事後要說清楚誰要求、准了什麼、實際做了什麼,靠的是哪些紀錄,這些紀錄又改不改得動。
第二個問題,就是 Day17 說要留到今天談的「改不動的稽核紀錄」,也就是 Day28 結尾那句話裡的「查得清」。
另外先講清楚範圍:今天補上的防線只擋寫入,不擋讀取和網路,所以 agent 還是讀得到你的金鑰;要怎麼把金鑰搬到它碰不到的地方,Day16 講過。要連讀取和網路一起關,得換一種沙箱,第五節最後會介紹微軟開源的 MXC。agent 改寫自己的規則和記憶,留到 Day30。
bun test我在做 Day21 那個套件(擋住沒審過的 extension 的 omp-extension-guard),請 Cursor 的 agent 照規格寫,先寫測試。要測的東西會去讀「使用者層」的設定,像 ~/.omp/agent/extensions/、~/.config/ 底下的檔,所以測試得先造一個假的家目錄,把假的設定放進去,每個測試跑完再清掉。agent 是這樣寫的(節錄,原樣):
// test/setup.ts:測試的預載,每個測試檔之前跑
const home = fs.mkdtempSync(path.join(os.tmpdir(), "eg-home-"));
process.env.HOME = home;
// test/fixtures.ts:每個測試跑完(afterEach)呼叫
export function resetUserLevel(): void {
for (const rel of [".omp", ".claude", ".codex", ".config", ".local", ".cache", ".aws", ".git-credentials", ".netrc", ".npmrc"]) {
fs.rmSync(path.join(os.homedir(), rel), { recursive: true, force: true });
}
}
讀起來很合理:先把 HOME 換成一個剛建的暫存目錄,之後所有路徑都從 os.homedir() 算,清理也只清「家目錄」底下那幾個資料夾。那個家目錄,應該是暫存的那個。
問題在一個前提:執行中改了 process.env.HOME,os.homedir() 會不會跟著變? Node 的文件寫,os.homedir() 在 POSIX 上「uses the $HOME environment variable if defined」。Bun 不會:它在行程啟動時就把家目錄定住了。我事後用一支只印 true/false 的探針重測(Bun 1.4.2):執行中把 HOME 換掉,process.env.HOME 是暫存目錄,os.homedir() 還是原本的家目錄;把同一行放進 bun test 的預載,測試裡讀到的也還是原本的家目錄;只有在行程啟動前就把 HOME 設好,os.homedir() 才是暫存目錄。
agent 其實問過自己這個問題。它在寫這段之前的紀錄裡寫著:執行中改 HOME,Bun 的 os.homedir() 到底會不會跟著變?接著用一句「我記得之前確認過,會」帶過,沒有跑一行程式去確認。Day20 那個套件裡就有一模一樣的預載,註解寫著這個測試套件「有自己的 HOME」;那一次沒出事,只因為它的清理只刪自己建的暫存目錄。這一次,多了一個刪 os.homedir() 底下東西的清理函式。
接著它在 ~/omp-extension-guard 裡跑測試:
timeout 180 bun test test/allowlist.test.ts test/inventory.test.ts 2>&1 | tail -60
這一行裡沒有 rm,沒有 ~,沒有任何一個家目錄裡的路徑。不管是你按下允許、比對 allowlist,還是交給 classifier 判斷,看到的都是這一行。
測試因為別的原因失敗了。agent 回頭除錯:使用者層的假設定怎麼找不到?它把 os.homedir() 印出來,是真的家目錄。再去看家目錄:.config、.local、.cache 不見了,.omp 被換成測試放進去、只剩 3 個項目的假版本。inventory 測試跑了兩次,每個測試結束,都清了一次「家目錄」。
被刪掉的,事後照之前 ls 的紀錄清點:
| 資料夾 | 裡面有什麼 | 刪掉之後 |
|---|---|---|
~/.omp |
omp 的設定(agent/config.yml,裡面設了自動載入 cursor-sdk 擴充)、存設定和憑證的 agent.db、sessions、logs、plugins |
omp 不再自動載入 cursor-sdk;設定、憑證、session 紀錄都沒了 |
~/.config |
至少有 GitHub CLI 的登入(gh/hosts.yml) |
登入沒了 |
~/.local |
至少有 gh 本身,和 pi CLI 的安裝 |
git 透過 gh 拿 GitHub 憑證,git push 失敗;pi 指令不見了 |
~/.cache |
各種快取 | 大多可以重建 |
清單裡另外幾個(.claude、.codex、.aws、.git-credentials、.netrc、.npmrc)這台機器上本來就沒有,算不上損失。所有程式庫、~/.ssh、~/.gitconfig、~/.bashrc 都還在。
接下來 agent 做的幾件事,後來都寫進了規則:停下來,什麼都不再寫,先唯讀地查還救不救得回來。沒有任何行程還開著被刪的檔(/proc/*/fd 裡沒有);檔案系統是 LVM 上的 ext4,沒有快照;sudo 要密碼,它不能、也不該自己動手做磁碟救援。它還發現,自己除錯時在真的 ~/.omp/agent/extensions/ 裡留下了一支測試用的 extension,真的 omp 下次啟動會自動載入它,於是只刪了那一個檔和它自己建的空資料夾。然後回報,讓我決定。我決定不救了,先把這次的教訓做成一道防線。
最後一點最值得記住:就算有人把那段測試從頭讀一遍,多半也會放行。 它先把 HOME 換成暫存目錄,之後才算路徑、才刪東西,看起來正是一段小心的程式。錯不在哪一行寫錯,而在一個關於執行環境的前提。讀程式碼的人、讀指令的規則,都看不到這種錯。
Day28 給 omp 的最後一條建議是「記錄誰要求、准了什麼、工具是否真的執行」。拿這次事故來對:
| 要查的 | 有沒有紀錄 | 在哪裡 |
|---|---|---|
| 誰要求 | 有 | Cursor 的 agent 對話紀錄:我請它照規格寫測試 |
| 准了什麼 | 有 | 同一份紀錄:那一行 bun test … |
| 實際做了什麼 | 沒有 | 沒有任何紀錄記下 fs.rmSync 刪了哪些資料夾 |
我在 2026-10-12 數了一次這段對話的紀錄:654 次工具呼叫,工具結果 0 筆。它記下每一行指令,不記指令印了什麼,更不記指令啟動的行程寫了、刪了什麼。到底刪了什麼,是我拿事前和事後的 ls 比出來、再回頭讀 fixture 的程式碼推回去的。紀錄的盲點,和核准的盲點是同一個:都只看得到那一行字。
紀錄放在哪裡,是第二個問題。Cursor 的對話紀錄在 ~/.cursor/projects/ 底下,omp 的 session 和 log 在 ~/.omp 底下,都在家目錄裡。這次被刪的就有 ~/.omp:如果那天跑測試的是 omp,它自己那段 session 會跟著一起消失;Cursor 的紀錄留下來,只因為清理的清單裡剛好沒有 .cursor。2026 年 7 月 Claude Code 的 issue #74539 是另一種結局:整個家目錄被刪,連 ~/.claude 也在內,回報的人寫,那份 session 紀錄原本「would have identified the exact command」;現在只剩猜測,猜的正是兩種看不出來的刪法:一段執行時才組出路徑的清理指令,或是 classifier 沒看到內容的一支輔助腳本。
第三個問題,是留下來的紀錄改不改得動。這一點我之前(2026-10-09)就實測過,兩邊都一樣:同一個使用者跑的程式改得動,產品重新載入時看不出來。細節在下面兩節。
先說清楚 Cursor 現在有什麼(2026-10-12 讀文件)。agent 跑終端機指令前要不要先問你,由 Run Modes 決定。3.6 版起預設是 Auto-review:allowlist 裡的直接跑,其他 shell 指令「run in the sandbox when possible」,進不了沙箱的交給一個 classifier 判斷;另外兩種是 Allowlist(沙箱可選)和 Run Everything(沒有沙箱,什麼都直接跑)。
沙箱在 Linux 上用 Landlock 和 seccomp,預設可以讀寫工作區和 /tmp,其他地方寫不進去。這正是擋得住這次刪除的那種東西:它是核心層級的限制,管的是行程實際做的事,不是指令長什麼樣子。bun test 如果在沙箱裡跑,測試裡的 fs.rmSync 要刪工作區外的 ~/.config,核心會直接拒絕。
其他每一層,看的都是那一行字,或 agent 自己的工具:
| Cursor 的機制 | 它看的是什麼 | 擋得到 bun test 裡的 fs.rmSync 嗎 |
|---|---|---|
| Allowlist | 指令文字的前綴,大小寫有別;文件寫「Not a security boundary」 | 不能,那一行就是 bun test |
| Auto-review classifier | 指令,必要時讀它要跑的腳本;文件寫「Auto-review is not a security boundary」 | 文件沒說它會讀到測試的 fixture;就算讀了,那段程式看起來是安全的 |
beforeShellExecution hook |
command、cwd、sandbox 三個欄位,只能 allow/deny/ask;出錯預設 fail-open |
不能,沒有一個欄位碰得到行程裡做的事 |
| File-Deletion/External-File Protection | agent 自動刪檔(「including rm commands」)、在工作區外建改刪檔 |
文件沒寫涵不涵蓋指令啟動的行程 |
| Checkpoints | agent 改過的檔 | 文件沒寫涵蓋終端機或子行程的改動;被刪的又在工作區外 |
| 規則 | 給模型的指引;企業版文件寫「rules are suggestions, not guarantees」 | 不能,那是模型的自律 |
| 沙箱(Linux:Landlock+seccomp) | 行程實際的寫入:工作區和 /tmp 以外寫不進去 |
能,只要那一行真的在沙箱裡跑 |
所以關鍵只剩一個問題:那一行,在不在沙箱裡?
這一次,它不在。 事後在同一個對話裡,從 agent 的終端機工具查它跑指令的 shell:文件說沙箱裡的行程會帶 CURSOR_SANDBOX=native,這個 shell 沒有;Landlock 和 seccomp 會留下的痕跡(seccomp 模式、no_new_privs),從這個 shell 一路往上查到 sshd,全都是 0。核心本身提供 Landlock ABI 8,不是核心太舊。
為什麼不在,文件給了兩個都對得上的理由,我沒辦法從紀錄確定是哪一個:
~/omp,測試卻在旁邊的 ~/omp-extension-guard 裡跑;對沙箱來說,那整個專案都在工作區外。在沙箱裡失敗的指令,也是「the agent can rerun it outside the sandbox. The classifier reviews that rerun.」kernel.apparmor_restrict_unprivileged_userns = 1),而那個套件沒裝。當時 Run Modes 選的是哪一種,我沒有記下來;Run Everything 本來就不用沙箱。不管是哪一個原因,結果都一樣:那一行帶著我完整的使用者權限在跑。
查的那一半,Cursor 這邊 2026-10-09 量過(Cursor 3.21.9、@cursor/sdk 1.0.32,只動測試自己造出來的紀錄,事後還原):
痛點是哪三件:
command、cwd,classifier 判斷指令和它讀到的腳本,規則是模型的自律。bun test 這一行,哪一層都看不出它會刪家目錄。omp 原生的答案更直接:它沒有替 bash 準備沙箱。文件寫得很明白:「Pattern approval is not containment. Once approved, a process keeps the shell's ambient filesystem, network, and subprocess access.」核准決定一行指令跑不跑;跑起來之後,它就有你 shell 的全部權限。所以「原生的 omp 擋不擋得住這次的刪除」,答案很短:擋不住,它也從來沒說要擋。
但 omp 留了一個 Cursor 的 beforeShellExecution 沒有的點:擴充可以改掉實際要跑的那一行。 每一個工具呼叫執行前,omp 會先問擴充一次 tool_call;擴充可以擋,也可以回一份改過的輸入。文件寫,改過的輸入會重新驗證,之後的排程、事件、存檔、核准閘門,看到的都是改過的那份:你核准的,就是真的會跑的。
這就給了平替的路:一支擴充把每一個 bash 呼叫,改成「先把自己的寫入權限縮到專案和暫存目錄,再執行原本那一行」。Landlock 是 Linux 核心的功能,一般使用者就能用,不用 root:一個行程可以放棄自己在某些目錄以外的寫入權,之後它啟動的每一個行程都會繼承,而且收不回來。
| Cursor(原生) | omp(原生) | omp(加一支擴充) | |
|---|---|---|---|
| 核心層級的寫入限制 | 有:Linux 用 Landlock+seccomp | 沒有 | 有:每個 bash 呼叫都在 Landlock 底下 |
| 邊界畫在哪 | 工作區+/tmp |
—— | 專案+暫存目錄+你列的地方,家目錄其餘部分一律保護 |
| 出口 | 寫工作區外的、沙箱裡失敗的,交給 classifier 或你決定在沙箱外跑 | —— | 沙箱跑不起來時,預設警告後只靠靜態檢查;可以設成整個擋下 |
| hook 能不能改要跑的指令 | beforeShellExecution 不能;另一支 preToolUse 文件寫可以回 updated_input |
tool_call 能,改過的會重新驗證、交給核准閘門 |
用的就是這一點 |
公平起見:Cursor 的 preToolUse 在文件上也能改寫工具輸入,同一招在 Cursor 理論上也做得到;這一篇只在 omp 上做了、測了。
我用預設什麼都沒加的 omp 18.2.8,在一個暫存的世界裡重演同一個形狀:家目錄裡放一個 precious/ 資料夾當 canary,專案裡放一支 cleanup.js,它和那次的 fixture 一樣,執行時才從 os.homedir() 算出要刪的路徑。照劇本回應的測試模型只叫一個工具:bash,指令是 bun cleanup.js。
結果:canary 被刪了,cleanup.js 還印出「cleaned the folder」。omp 照常核准、照常執行,它的 bash 工具沒有任何一步知道這支腳本要刪什麼。
靜態檢查也救不了這一場。所謂靜態檢查,是在 tool_call 那一刻讀每一個工具呼叫、算它要寫到哪裡。它讀得到 rm -rf ~/precious,擋得下來;它讀不到 bun cleanup.js 裡面那行 fs.rmSync。下一節那支 guard 的靜態層,就是照設計放過了這一行,交給沙箱。
紀錄這一面,omp 原生記得比較多:session 檔是一份 append-only 的 JSONL,每一個工具結果(toolResult)都會記進去(文件),bun cleanup.js 印出的那一行會留在紀錄裡。但它不記行程寫了、刪了哪些檔;它放在 ~/.omp,正是這次被刪的那一個;它也改得動。2026-10-09 對 session 檔的副本做四種竄改:改一個字、刪一行、調換順序、改完再把後面的雜湊連鎖重算,--resume 全部照吃、0 次警告;改一個字和重算那兩場,被改的那一句還被 agent 當成自己說過的話覆述出來。
把原生 omp 在這個情境的能力收成一張表:
| 原生 omp | |
|---|---|
rm -rf ~/precious 這種寫在指令上的刪除 |
bash.patterns 的核准規則擋得到,但文件自己說那是核准、不是隔離 |
bun cleanup.js 裡、執行時才算出路徑的刪除 |
擋不到,已實測:canary 被刪 |
| 指令啟動的子行程 | 繼承 shell 的全部權限 |
eval(omp 裡跑的 JS、omp 啟動的 Python kernel) |
和 omp 一樣的權限,不在任何沙箱裡 |
| 擴充能不能改要跑的指令 | 能,tool_call 回一份新的輸入 |
| 事後查「實際做了什麼」 | session 檔記下工具結果,不記行程寫了、刪了哪些檔 |
| 紀錄放在哪、改不改得動 | ~/.omp,在家目錄裡;改了 resume 照吃、0 次警告(2026-10-09 實測) |
方向很清楚,要補三件事:讀得懂的,在工具呼叫那一刻就擋,給模型一個清楚的理由;讀不懂的,交給核心:讓它跑,但讓它寫不進家目錄;紀錄要放在 agent 的程式碰不到的地方,而且改了要看得出來。

靜態檢查在 omp 行程裡,讀得懂的直接擋,理由回給模型;bash 呼叫被改寫成先由 helper 用 Landlock 縮小寫入權、再換成原本那一行,所以它啟動的每一個行程都寫不進家目錄。核准閘門判的是改寫後的那一行。write、edit、eval 和 omp 自己寫的 session 紀錄都在沙箱外。紅色的格子是擋下來的結果。下面逐層說明。
1. 靜態檢查(每個平台)。 我寫了一支擴充 omp-home-guard。tool_call 時算出 write、edit(包括改名、合併 patch 裡列出的檔)、ast_edit 會寫到哪裡,經過 ~、$HOME、..、符號連結都算,寫進受保護的地方就擋,並告訴模型哪裡可以寫。bash 讀得懂的部分:重新導向;rm、mv、cp、tee、dd、sed -i、find -delete、git -C、tar、curl -o 這類指令的目標;cd 進家目錄;從家目錄指派的變數;被家目錄餵資料的 xargs;bash -c、python -c、node -e 裡的內嵌程式碼。eval 的程式碼只要提到家目錄,又會寫檔、刪檔或開行程,就擋;提到家目錄的那一句即使在同一個 kernel 的前一次呼叫裡,也算,直到 kernel 被重設。
2. Landlock 沙箱(Linux)。 每一個 bash 呼叫都被改寫成:先啟動一支小 helper,它用 Landlock 把自己的寫入、建立、刪除、改名、截斷權限縮到可寫的地方(專案、暫存目錄、你的 allowWrite),再把自己換成 bash -c '<原本的指令>',在原本的目錄裡跑。輸出、結束碼都照舊;寫到別處,核心回 EACCES。helper 要是沒辦法先把自己限制住,就不跑原本的指令,直接回報失敗:結束碼 126,在 shell 的慣例裡就是「這個指令沒有被執行」。寧可不跑,也不在沒保護的情況下照跑。改寫時要避開幾個自己的坑:
--probe 跑一次,真的限制住自己才算可用。bunfig.toml 預載、載入那裡的 .env,專案裡的這兩樣東西不能在限制生效前先跑。BUN_OPTIONS:實測,裡面的 --preload 會比 helper 自己的程式更早跑;helper 再把原值還給你的指令。cwd 原封不動:omp 照常解析它、照常套用專案的 direnv,skill:// 這類內部網址也留在 omp 會展開的位置。3. 給寫測試的 agent 的規矩。 guard 管的是 omp 的 agent,那次動手的卻是 Cursor 的 agent。所以另外做了兩樣東西。一條每個對話都套用的 Cursor 規則:不准從 os.homedir()、$HOME、~ 算出要刪或要寫的路徑;測試的家目錄要在行程啟動前就換好;每一次遞迴刪除,都要先確認目標在同一段程式自己建的暫存目錄裡;萬一刪到了,停下來、不再寫入、回報。另一樣是測試的 runner:它先建一個暫存根目錄,再帶著 HOME、TMPDIR 啟動 bun test;預載檢查 os.homedir() 真的在那個根目錄裡,不在就拒絕執行。規則是給模型的指引,Cursor 自己的文件也說規則不是保證;真正擋得住的是 runner 和預載。文末附了 runner 的程式。
同一個暫存世界,加上 guard,照劇本跑四個工具呼叫:
rm -rf ~/precious:靜態檢查直接擋下。模型收到的是「omp-home-guard blocked bash: it would change ~/precious, which is in your home directory outside the places this session may change (…)」。bun cleanup.js:靜態檢查放行,沙箱擋下。模型收到的是 Bun 的錯誤 EACCES: permission denied, rm '<run>/home/precious',結束碼 1;canary 還在。echo built > out.txt && cat out.txt:在專案裡照常寫、照常印出 built。write 到 ~/.bashrc:擋下,檔案沒有被建出來。omp 的事件流裡,bash 工具收到的確實是改寫過的那一行。另一場在沙箱裡跑 cat skill://demo/SKILL.md,以及在 cwd: "skill://demo" 底下跑 pwd,兩個都照常運作。不呼叫模型的測試有 76 條,全過(11 秒,不用網路),其中 9 條直接在真的 Landlock 上跑 helper:允許的地方寫得進去;rm -rf、覆寫、建立都被擋;bun -e 的 rmSync 和一支自己組路徑的腳本,都在子行程裡被擋;輸出、結束碼 5、工作目錄照舊;Landlock 最多疊 16 層,第 17 層的 helper 限制不了自己,就回報失敗(結束碼 126),指令沒跑。成本是每個 bash 呼叫多約 0.23 秒(20 次的中位數),大部分是 Bun 啟動的時間。
沙箱順便守住了紀錄。它保護的是整個家目錄,~/.omp、~/.cursor 都在裡面,所以 agent 跑的指令和那些指令啟動的程式,刪不掉、也改不了 agent 自己的紀錄。這一點是從家目錄的測試推出來的,沒有對這兩個資料夾單獨跑。omp 自己寫 session 不受影響:寫的是 omp 的行程,它不在沙箱裡。
要讓紀錄改了看得出來,用的是一本雜湊鏈帳本,2026-10-09 實測過。一支擴充掛在 session_start、tool_call、tool_result、turn_end,每個事件往一本帳 append 一筆,每筆 h = sha256(上一筆的 h + 這一筆),鏈頭另外寫到 session 目錄以外的一個錨點;帳本只記事件種類、工具名和輸入的欄位名,不記內容。開場先自報到一筆,「沒掛 guard」和「掛了但沒事」分得出來。驗證器自測 12 種組合全部如預期:沒有鏈的純 JSONL,四種竄改都驗不出來;有鏈的本機驗法抓到改、刪、換;連雜湊一起重算的,只有對上錨點才抓得到。對 omp 那四種竄改,結果一樣。
補不上的也要講清楚:那次的錨點只是另一個暫存目錄,帳本和錨點都在同一台機器上,同一個使用者都改得動。真正改不動,要把鏈頭即時送到這台機器以外,這一步沒做。而且帳本記下的仍然是工具事件,不是行程寫了、刪了哪些檔;被沙箱擋下的寫入,只出現在指令的輸出裡(EACCES)。
| 原生 omp | 補上之後 | 證據 | |
|---|---|---|---|
rm -rf ~/precious |
跑了就刪 | 靜態檢查擋下,告訴模型哪裡可以寫 | 已實測 |
bun cleanup.js(執行時才算路徑) |
canary 被刪 | 沙箱擋下(EACCES),canary 還在 |
已實測 |
| 專案裡的寫入 | 照常 | 照常 | 已實測 |
write ~/.bashrc |
照寫 | 擋下 | 已實測 |
| 子行程 | 繼承全部權限 | 繼承 Landlock 的限制 | 已實測(bun -e、自己組路徑的腳本) |
| helper 自己壞掉 | —— | 不跑指令,回報失敗(結束碼 126) | 已實測(第 17 層) |
| 沙箱跑不起來(macOS、舊核心) | —— | 預設警告,靜態檢查變嚴格;設 onUnavailable: "block" 就整個擋 |
已實測(指定一個不存在的 Bun 來模擬) |
| 每個 bash 呼叫的成本 | —— | 多約 0.23 秒 | 已實測(20 次中位數) |
agent 的程式刪自己的紀錄(~/.omp、~/.cursor) |
刪得掉,這次 ~/.omp 就沒了 |
沙箱擋住,家目錄整個受保護 | 推論(家目錄的測試) |
| 紀錄被改 | resume 照吃、0 次警告 | 掛雜湊鏈帳本:改、刪、換本機驗得出,重算靠錨點驗得出 | 已實測(2026-10-09) |
| 補上之後的 omp | Cursor IDE(文件+這次的觀察) | |
|---|---|---|
| 擋得住指令啟動的行程刪家目錄嗎 | 能,每個 bash 呼叫都在 Landlock 底下 | 在沙箱裡能;這次那一行不在沙箱裡 |
| 邊界畫在哪 | 家目錄除了專案、暫存目錄、allowWrite,其餘全部保護,不管專案放在哪 |
工作區+/tmp;要寫工作區外的指令不進沙箱 |
| 進不了沙箱時 | 你設定:警告,或整個擋 | 交給 classifier 或你;遠端缺 AppArmor 套件時沙箱建不起來 |
| 看得出那一行有沒有在沙箱裡嗎 | session 通知寫明,每個 bash 呼叫都被改寫 | 文件說會標示沙箱外執行;hook 有 sandbox 欄位,沙箱裡有 CURSOR_SANDBOX |
| 只看指令文字的那幾層 | 靜態檢查,讀得懂的直接擋 | allowlist、hook、classifier、規則 |
| 紀錄記下「實際做了什麼」嗎 | session 記工具結果,不記檔案操作 | 對話紀錄不記工具結果(0 筆),也不記檔案操作 |
| 紀錄改了看得出來嗎 | 原生看不出來;掛雜湊鏈帳本加錨點才驗得出 | 看不出來;hook 的帳本可以被靜默拿掉,失敗時放行 |
兩邊的差別不在誰有沙箱:Cursor 原生就有,omp 原生沒有。差別在邊界由誰畫、出口開在哪裡。Cursor 的邊界是工作區,出口留給 classifier 和你;補上之後的 omp,邊界是「家目錄裡除了這幾個地方都不准寫」,出口是沙箱跑不起來的時候:預設警告後只靠靜態檢查,要關死得自己設成 block。紀錄那一面,兩邊原生都一樣改了看不出來;omp 多的,是擴充能擋、能自報到,帳本可以接成一條鏈。
| 限制 | 現在 |
|---|---|
| 讀取和網路 | 不管。家目錄裡的祕密讀得到,也送得出去;要關,得換成下面 MXC 這種預設全關的沙箱 |
| 權限、擁有者、時間戳 | Landlock 管寫入、建立、刪除、改名、截斷,不管這些中繼資料;靜態檢查擋得到它讀得懂的 chmod、chown、touch |
| 只有 bash 在沙箱裡 | eval、omp 自己的寫入、其他擴充、MCP 工具、你自己的 ! 指令,都不在裡面 |
| 只有 Linux | macOS、Windows 只有靜態檢查 |
| shell 狀態 | 沙箱裡的 bash 呼叫之間,export 的變數、定義的函式不會留下來 |
| 真的模型、開著核准 | 實測用照劇本的測試模型、--approval-mode yolo;真的模型被擋之後怎麼反應、核准提示怎麼顯示改寫過的指令,都沒測 |
| guard 本身 | 裝在 agent 寫得到的地方就可能被改,要裝在專案之外 |
| 紀錄的機外錨點 | 鏈頭沒有送出這台機器;帳本和錨點,同一個使用者都改得動 |
| 行程做了什麼 | 沒有一份紀錄記下成功或被擋下的檔案操作;被擋的寫入只在指令的輸出裡 |
上面那張表的第一列,Landlock 這一層補不上:它管寫,不管讀取和網路。要連這些一起關,得換一種沙箱。微軟開源的 MXC(Microsoft eXecution Container,MIT 授權)就是這一類,它對自己的介紹是「a sandboxed code execution system for running untrusted code (model output, plugins, and tools)」。以下只讀了它的文件和原始碼(1.0.0,2026-10-12),沒有在這台機器上跑。
它有五個關鍵概念:
$HOME」,在沙箱裡都看不到:~/.ssh、~/.aws/credentials 連讀都讀不到。要用的路徑逐一列進唯讀或可寫;要遮掉的列進 deniedPaths,目錄用一個空的 tmpfs 蓋住,檔案用 /dev/null 蓋住。egress.default: "deny" 會給沙箱一個自己的網路 namespace,裡面只有它自己的 loopback。要放行特定位址,則改用一個接上 slirp4netns 的網路 namespace,在裡面用 iptables 寫規則;需要的工具少了一樣就直接失敗,文件寫明「neither ever falls back to sharing the host network namespace」。--clearenv 建起來,「so host secrets can't leak into untrusted code」:預設只給 PATH、TERM 這幾個基本變數,其餘要你明列。learningModeLogging),照擋、照記。另一種是全部放行、全部記錄的稽核模式(permissiveLearningMode;Windows 執行器的 --audit 也是這一類),它會關掉防護,文件警告「Never use it to run untrusted code」。
上半是分層:不管從哪一種呼叫端進來,都是同一份請求,驗過之後由引擎依平台選後端。下半是 Linux 預設的 bubblewrap 後端在沙箱裡關掉的三樣東西。圖只畫文件和原始碼寫的,沒有在這台機器上跑;其他後端只標出名字。
代價也寫在文件裡:bubblewrap 後端要 bubblewrap 0.5 以上,還要非特權的 user namespace。這台機器兩樣都沒有:bubblewrap 沒裝,一般行程建 user namespace 會被拒(unshare --user 回「Operation not permitted」),正是第二節擋下 Cursor 沙箱的那個條件。Cursor 的 Linux 沙箱也是這個方向:網路預設擋,3.23 起還能把讀取限制在工作區;它和 MXC 一樣要用到 user namespace。Landlock 不用 namespace,所以在這台機器上,先補得上的是寫入這一層。
兩層可以疊起來用:Landlock 擋寫,namespace 沙箱再把讀取、網路、環境變數關起來。omp 這邊的接法和今天一樣:在同一個 tool_call 改寫點,把 bash 呼叫交給 MXC 的執行器(Linux 是 lxc-exec)或 Node SDK 的 spawn。這一步,這一篇沒有做,也沒有測。
第一步,知道家目錄裡哪些東西刪了就回不來。 不是程式碼,程式碼在 git 裡;是那些沒人放進版本控制的:CLI 的登入(gh、雲端的 CLI)、agent 自己的設定和憑證、本機裝的工具。列出來,備份起來。誰都該做到這一步。
第二步,讓測試碰不到真的家目錄。 agent 寫的測試要造假家目錄,就得在行程啟動前把 HOME 換好(runner 帶著環境變數啟動 bun test),預載再確認 os.homedir() 真的在暫存根目錄裡,不在就拒絕執行;每一次遞迴刪除,都先確認目標的真實路徑在同一段程式自己建的暫存目錄裡。會讓 agent 寫測試、跑測試的,做到這一步。
第三步,讓 agent 的 shell 真的進沙箱,而且確認它在裡面。 用 Cursor:Run Mode 用 Auto-review,或打開沙箱的 Allowlist;Linux 的遠端環境要裝 Cursor 的 AppArmor 套件;在 agent 的終端機裡看 CURSOR_SANDBOX 有沒有設;專案放在工作區裡,免得指令一開始就進不了沙箱。用 omp:裝 omp-home-guard 這類擴充,看 session 開頭的通知有沒有寫「Landlock sandbox」。讓 agent 自動跑指令的,一定要做到這一步。
第四步,把「進不了沙箱」當成要停下來的事。 核准、allowlist、classifier 看的是那一行字,只能當意圖檢查,不能當圍牆。沙箱跑不起來時,omp-home-guard 設 onUnavailable: "block";Cursor 這邊,沙箱外執行的提示不要順手按掉,安全用的 hook 設 failClosed: true。要讓它沒人看著也跑(排程、subagent,Day22、Day24),這一步少不了。
第五步,讓事後查得清。 先知道你的 agent 紀錄放在哪:Cursor 的對話紀錄、omp 的 session 都在家目錄裡,前者只記指令,後者記到工具結果,兩者都不記行程寫了、刪了什麼。要拿來當證據,紀錄得離開 agent 寫得到的地方,而且改了要看得出來:每個工具事件進一條雜湊鏈,鏈頭即時送到這台機器以外。要對別人交代 agent 做了什麼的,做到這一步。
留在 Cursor 的話:沙箱是你手上唯一管得到行程實際動作的東西。先確認它在你的機器上真的建得起來,再確認 agent 的工作落在工作區裡;其餘幾層,allowlist、hook、規則,都只看得到那一行字。事後要查,別只靠對話紀錄:它只有指令、沒有結果,改了產品也看不出來。
回到那一行 bun test,試著問自己四個問題。
第一題:那一行指令看一百遍,也看不出它會刪家目錄。為什麼?
因為刪檔不在指令裡,在指令啟動的程式裡;而且那段程式本身看起來也是安全的:它先把 HOME 換成暫存目錄,才去算路徑。錯的是一個關於執行環境的前提:Bun 在行程啟動時就定住了 os.homedir()。核准、allowlist、hook、classifier、規則,看的都是文字;這種錯不在文字裡。
第二題:測試明明先把 HOME 換成暫存目錄,為什麼刪到的還是真的?
因為換得太晚。預載在 Bun 啟動之後才跑,process.env.HOME 變了,os.homedir() 沒變(重測過的三種情況裡,只有「啟動前就設好」那一種,os.homedir() 才是暫存目錄)。所以修法不是換得早一點,而是讓 runner 帶著 HOME 去啟動 Bun,預載只負責確認、不負責換。再加一道:所有遞迴刪除,都先檢查真實路徑在自己建的暫存目錄裡。
第三題:Cursor 在 Linux 上明明有 Landlock 沙箱,為什麼這次沒擋下來?
因為那一行不在沙箱裡。事後查 agent 的 shell:沒有 CURSOR_SANDBOX,seccomp 和 no_new_privs 一路都是 0。文件寫的兩個出口,這次都對得上:要寫工作區以外的指令不進沙箱,而測試在工作區外的資料夾跑;遠端環境沒裝 Cursor 的 AppArmor 套件,Ubuntu 又限制了 user namespace。沙箱只保護真的在裡面跑的指令,所以要去確認,而且要讓「不在裡面」變成「停下來」,而不是「照跑」。
第四題:事後要查「實際做了什麼」,為什麼手上的紀錄幫不上忙?
因為紀錄記的也是那一行字。Cursor 的對話紀錄記下每一行指令,工具結果是 0 筆;omp 的 session 記到工具結果,一樣不記行程寫了、刪了哪些檔。紀錄又放在家目錄裡,和家目錄一起被刪的風險一樣;留下來的,同一個使用者改得動,兩邊產品重新載入時都看不出來(2026-10-09 實測)。要查得清,紀錄得放在 agent 碰不到的地方,改了要驗得出來,鏈頭還得離開這台機器。
給 agent 一個 shell,等於把你整個家目錄交給它寫的每一段程式。先決定它寫得到哪裡,再讓核心替你守住其餘的地方;要拿來查它的紀錄,也要放到它碰不到的地方。
明天 Day30 換一個方向:agent 不只會刪錯東西,還會改寫那些之後要拿來管它的規則和記憶。它改的那一份,跟後來真正生效的那一份,是不是同一份?
第 1 步:在一個新的暫存目錄裡跑一行 Bun,看 os.homedir() 會不會跟著改掉的 HOME。這一步什麼都不刪:
cd "$(mktemp -d)" && bun -e 'const os = require("os"); const before = os.homedir(); process.env.HOME = process.cwd(); console.log(os.homedir() === before)'
印出 true,代表 HOME 改了,os.homedir() 還是原本的家目錄。
第 2 步:請 agent 在它的終端機工具裡跑下面這一行,看它跑指令的 shell 在不在沙箱裡。在 Linux 上,Cursor 沙箱裡的行程 CURSOR_SANDBOX 會是 native;它的沙箱用 seccomp,所以 Seccomp 應該不是 0:
grep -E '^(NoNewPrivs|Seccomp):' /proc/self/status; echo "CURSOR_SANDBOX=${CURSOR_SANDBOX:-unset}"
第 3 步:用 omp-home-guard 的 helper 親眼看一次 Landlock 擋寫入。全程只動這一步自己建的暫存目錄,<omp-home-guard> 換成你 clone 下來的位置:
d=$(mktemp -d) && mkdir "$d/allowed" "$d/other" "$d/launch" && echo keep > "$d/other/keep.txt"
cd "$d/launch" && bun --no-env-file <omp-home-guard>/src/sandbox-exec.ts --cwd "$d/allowed" --allow "$d/allowed" -- "rm -f '$d/other/keep.txt'; echo ok > made-here.txt"
cat "$d/other/keep.txt"; ls "$d/allowed"; cd / && rm -rf "$d"
rm 會回「Permission denied」,keep.txt 還在;made-here.txt 則照常寫進允許的那個資料夾。
第 4 步:把家目錄裡刪了就回不來的東西列出來:登入、憑證、agent 的設定、本機裝的工具。每一樣問一次:備份在哪裡?如果明天它不見了,要多久才能恢復?
今天對應的威脅: T2(抄近路)和 T3(收不回的事)。這次沒有攻擊者:agent 用一句「我記得確認過」代替了驗證,這是 T2,它說的不等於它做的;結果是一個收不回的刪除,這是 T3,照 Day30 的說法:別人或它自己,驅動它去做收不回的事。OWASP Agentic Top 10 2026 的 ASI02「Tool Misuse and Exploitation」寫的正是這個形狀:「the agent operates within its authorized privileges but applies a legitimate tool in an unsafe or unintended way - for example deleting valuable data」;agent 自己生成、自己執行的程式碼,對應 ASI05「Unexpected Code Execution」,它的情境一就是 Replit:agent「generates and executes unreviewed install or shell commands in its own workspace, deleting or overwriting production data」。兩條的緩解都寫到沙箱:ASI02 的「Run tool or code execution in isolated sandboxes」,ASI05 的「Never run as root. Run code in sandboxed containers」。事後查證那一半,ASI02 的緩解第 8 條也寫了:「Maintain immutable logs of all tool invocations and parameter changes」;這次手上的紀錄,既不完整,也不是改不動的。本篇的實測沒有重演刪除:所有刪除都對著測試自己建的 canary,Bun 的探針不刪任何東西。
引用來源:
正文用事情本身來講,場次代號和檔案路徑收在這裡。以下路徑相對於 research_folder_omp_vs_Cursor/。
| 正文裡的那一段 | 紀錄 |
|---|---|
| 事故經過:預載和清理函式、agent 跑的指令、被刪的四個資料夾和裡面的東西、怎麼發現、事後查了什麼 | 那次 Cursor agent 對話的紀錄(2026-10-12);清點以事前的 ls 為準 |
| agent 寫之前問過自己,用「我記得之前確認過」帶過 | 同一份紀錄 |
| Day20 套件裡同樣的預載和註解 | omp-skill-guard v0.1.0 的 test/setup.ts |
Bun 的 os.homedir() 不跟著執行中的 HOME 變(三種情況) |
verification/day29-home-directory-deletion/homedir-probe.ts → homedir-probe-output.txt |
| agent 的 shell 不在 Cursor 的沙箱裡、這台機器少了的那一塊 | verification/day29-home-directory-deletion/cursor-shell-status.sh → cursor-shell-status.txt |
| MXC 的前置條件:bubblewrap 沒裝、一般行程建 user namespace 被拒 | verification/day29-home-directory-deletion/mxc-prereq-check.sh → mxc-prereq-check.txt |
原生 omp:bun cleanup.js 刪掉 canary |
verification/home-guard/live-2/baseline/1-script-deletes-canary |
加上 guard:rm -rf 被靜態擋、腳本被沙箱擋、專案寫入照常、~/.bashrc 被擋 |
verification/home-guard/live-2/guard/1-static-and-sandbox |
沙箱下 skill:// 照常 |
verification/home-guard/live-2/guard/2-internal-urls |
| 每個 bash 呼叫多約 0.23 秒 | verification/home-guard/nfr4-latency.txt |
| 76 條測試、真的 Landlock、第 17 層以 126 結束 | home-guard/test/(bun run test) |
| 這段對話的紀錄:654 次工具呼叫、0 筆工具結果 | 2026-10-12 數這次 Cursor agent 對話的紀錄 |
| 159 份對話紀錄、0 個完整性欄位、0 筆工具結果 | verification/day29-home-directory-deletion/record-tamper/runs/no-model/inventory.json |
| 對話紀錄是衍生副本:改 4 處,回放和 resume 仍是原值 | verification/day29-home-directory-deletion/record-tamper/runs/S29-t1-jsonl |
| SDK blob 換成等長的假內容、id 不動:照收,讀取和 resume 0 次完整性錯誤 | verification/day29-home-directory-deletion/record-tamper/runs/S29-t2-blob |
| hook 帳本:記 4 筆、改了驗得出、拿掉後 0 成長、失敗照跑 | verification/day29-home-directory-deletion/record-tamper/runs/S29-hook |
| omp session 四種竄改,resume 0 次警告,兩場覆述被改的句子 | verification/day29-home-directory-deletion/record-tamper/runs/O29-edit、O29-delete、O29-reorder、O29-recompute |
| 雜湊鏈:開場自報到、自測 12 種組合如預期、四種竄改的驗法結果 | verification/day29-home-directory-deletion/record-tamper/runs/O29-baseline、H29-chain-selftest;擴充 verification/day29-home-directory-deletion/record-tamper/hash-chain-hook.ts、驗證器 chain-verify.ts |
| 圖一的每一格 | home-guard/src/index.ts(session_start、tool_call、通知)、src/sandbox.ts(prepareSandbox、wrapCommand)、src/sandbox-exec.ts(以 126 結束、換成 bash -c);omp 的 extensibility/extensions/wrapper.ts:203-250(先問擴充,再過核准閘門) |
| 圖二的每一格(只讀,沒有跑) | mxc-main.zip 裡的 README.md(各平台後端)、docs/development/architecture/repository-architecture.md(分層、mxc_ffi、mxc_common、mxc_engine)、docs/backends/bwrap/bubblewrap-backend.md(沙箱裡關掉的三樣)、src/tools/{lxc,wxc,mxc_darwin}/Cargo.toml(執行器名稱)、sdk/node/src/native-library.ts(Node SDK 在行程裡載入 mxc_ffi) |
| 兩張圖的 SVG、PNG | day29-home-directory-deletion-fig1、-fig2 的 .svg 和 .png,由 verification/day29-home-directory-deletion/render-figs.mjs 從文中的 Mermaid 產生,結果在同一個資料夾的 render-figs.json |
guard 在 home-guard/,另外發布成 omp-home-guard 0.1.0:src/index.ts(擴充本體)、src/policy.ts(靜態檢查)、src/sandbox.ts(改寫 bash 呼叫)、src/sandbox-exec.ts 和 src/landlock.ts(helper)。給 Cursor 的規則在 omp repo 根目錄的 .cursor/rules/home-directory-safety.mdc。故事裡那段預載和清理函式是 Day21 套件當時的版本,之後已改成文末附錄的寫法。紀錄那一半的實測,紀錄和程式都在 verification/day29-home-directory-deletion/record-tamper/。
實驗條件:live 每種情況跑一次。omp 18.2.8 從原始碼跑(bun packages/coding-agent/src/cli.ts),RPC 模式、--approval-mode yolo、關掉 LSP、rules、自動標題;模型那一步是 home-guard/test/scripted-provider.ts,照劇本回工具呼叫,並記下它收到的每一個請求,不是真的模型。每一場有自己的 HOME(裡面放 precious/keep.txt 當 canary)、TMPDIR、專案(放在那個 HOME 底下的 work/repo);guard 用 -e 載入,不讀設定檔。第一次 live 的 guard/2-internal-urls 有一個檢查寫錯(omp 會在 bash 結果後面加一行 Wall time,檢查拿整段結果去比),行為是對的;修好檢查後的重跑在 live-2/,第一次的紀錄留在 live/。測試和 live 都由 runner 帶著暫存的 HOME 啟動,跑前跑後比對我真正家目錄的清單,一模一樣。核心 Linux 7.0、Landlock ABI 8、Bun 1.4.2、x86_64。紀錄那一半是 2026-10-09 的實測:Cursor 3.21.9、@cursor/sdk 1.0.32、omp 18.2.8,模型 claude-opus-5-5;Cursor 的竄改都在隔離的 SDK 專案裡,omp 的竄改都在 session 檔的副本上,動前備份、動後逐位元組還原,每種情境跑一次。紀錄裡的路徑換成 <run>、<package>、<omp>、<bun>;掃過 email、金鑰、家目錄絕對路徑,都是 0。omp 的行號,docs/ 相對原始碼根目錄,其餘相對 packages/coding-agent/src/。
docs/bash-tool-runtime.md:46:「Pattern approval is not containment. Once approved, a process keeps the shell's ambient filesystem, network, and subprocess access.」docs/approval-mode.md:72、docs/settings.md:182:bash.patterns 是核准規則,不是行程或檔案系統的隔離docs/extensions.md:338:tool_call 可以擋,也可以改執行的 input;改過的會重新驗證,排程、事件、存檔、核准閘門看到的都是改過的docs/extension-loading.md:273:「Extensions are not sandboxed (same process/runtime).」docs/session.md:67(session 在執行時只追加)、173(toolResult:每一個工具結果的紀錄)、484(寫入沒有 fsync)packages/coding-agent/src/ 底下)
extensibility/extensions/wrapper.ts:210-250(擴充回的 input 取代執行參數,再進核准閘門)、session/agent-session.ts:4166-4183
tools/bash.ts:845-856(沒給 cwd 時才把開頭的 cd 抽出來)、908(cwd 的解析)tools/bash-skill-urls.ts:28-29、208-276、364-412(內部網址在較長的引號文字裡不展開;整段加引號的網址照樣展開)exec/bash-executor.ts:116-146(direnv 依呼叫的目錄載入)CURSOR_SANDBOX(Linux 是 native);預設可寫工作區和 /tmp;File-Deletion、External-File Protection;更新紀錄 3.6「Auto-review shipped as the recommended default」type 預設 workspace_readwrite,insecure_none 整個關掉沙箱;additionalReadwritePaths
beforeShellExecution 的輸入 command/cwd/sandbox,輸出 allow/deny/ask;出錯預設 fail-open,failClosed;preToolUse 的 updated_input
mxc-main.zip,sha256 開頭 0ae0d5ece2218577,2026-10-12)
README.md:定位(「a sandboxed code execution system for running untrusted code (model output, plugins, and tools)」)、各平台的預設與其他後端、呼叫端要指定的三件事(容器種類、規則、指令)、--audit「turns off all sandbox security」docs/backends/bwrap/bubblewrap-backend.md:bubblewrap 0.5 以上與 user namespace 的前置條件;預設全關的檔案系統(「the caller's $HOME」看不到);deniedPaths 用 tmpfs 或 /dev/null 遮;egress.default: "deny" 用 --unshare-net,放行規則用 slirp4netns 加 iptables,缺工具就失敗;--clearenv
docs/logging-access-denied.md:learningModeLogging(照擋照記)與 permissiveLearningMode(全放全記),只在 Windows$HOME environment variable if defined.」這台機器沒裝 Node,未實測rm -rf tests/ patches/ plan/ ~/;同文整理了 2025-10-21 的 GitHub issue #10077(權限系統開著,rm -rf 從根目錄刪起)和 2025-11-28 的 #12637(沒加引號的 rm -rf ~);Reddit 原貼
rmdir /s /q d:\;AI Incident Database #1433,事件日期 2025-11-27這是事故之後 runner 和預載的寫法(取自 home-guard/test/run.ts、test/setup.ts、test/fixtures.ts,略去一部分)。重點是:runner 先建好暫存根目錄,再帶著 HOME 去啟動 bun test;預載不換 HOME,只確認 Bun 實際用的 os.homedir() 在那個根目錄裡;刪東西一律經過一個只准刪根目錄裡面的函式。
// test/run.ts:`bun run test` 跑的是這支,不是直接 `bun test`
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "hg-suite-")));
fs.mkdirSync(path.join(root, "home"));
fs.mkdirSync(path.join(root, "tmp"));
const env = { ...process.env, HOME: path.join(root, "home"), TMPDIR: path.join(root, "tmp"), HG_TEST_ROOT: root };
const child = Bun.spawn(["bun", "test", ...process.argv.slice(2)], { env, stdio: ["inherit", "inherit", "inherit"] });
const code = await child.exited;
fs.rmSync(root, { recursive: true, force: true });
process.exit(code);
// test/setup.ts:預載只檢查,不換
const root = process.env.HG_TEST_ROOT;
if (!root || !fs.existsSync(root) || !inside(os.homedir(), root) || !inside(os.tmpdir(), root)) {
throw new Error(`Refusing to run: the home directory (${os.homedir()}) is not inside a temporary test root. Run the tests with \`bun run test\`.`);
}
// test/fixtures.ts:每一次遞迴刪除都經過這裡
export function safeRemove(target: string): void {
let real: string;
try {
real = fs.realpathSync(target);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
throw err;
}
const rel = path.relative(TEST_ROOT, real);
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) throw new Error(`refusing to delete ${target}: outside the test root`);
fs.rmSync(real, { recursive: true, force: true });
}